Skip to content

feat(examples): add FastAPI Realtime Rule Engine example - #1519

Merged
grdsdev merged 21 commits into
mainfrom
claude/beautiful-bouman-d7a503
Nov 3, 2026
Merged

grdsdev merged 21 commits into
mainfrom
claude/beautiful-bouman-d7a503

Conversation

@grdsdev

@grdsdev grdsdev commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a complete examples/fastapi/ example application that stress-tests the async Realtime path of supabase-py. The example is a FastAPI Realtime Rule Engine: users define watch rules ("when tasks.status changes to done, broadcast to channel #team-alerts"), and a Python background task holds a persistent AsyncRealtimeClient connection, subscribes to Postgres Changes, evaluates matching rules, and broadcasts enriched events — all from Python.

What's included

  • examples/fastapi/main.py — FastAPI app with lifespan managing the service role client and engine task
  • examples/fastapi/engine.py — AsyncRealtimeClient background coroutine: Postgres Changes subscription + server-side broadcast
  • examples/fastapi/dependencies.py — Per-request JWT client injection via AsyncClientOptions header mutation
  • examples/fastapi/routers/ — Auth, tasks, and rules routers with RLS-enforced PostgREST queries
  • examples/fastapi/templates/ — Jinja2 HTML shell; browser uses @supabase/supabase-js CDN for Realtime, presence, and auth
  • examples/fastapi/supabase/migrations/001_initial.sql — Tables (tasks, rules, rule_events) with RLS policies; ALTER PUBLICATION supabase_realtime ADD TABLE tasks
  • examples/fastapi/friction_log.md — 6 documented SDK pain points surfaced during implementation
  • examples/fastapi/README.md — Setup and run instructions

SDK paths stressed

Call Purpose
acreate_client(url, service_key) Long-lived service role singleton
acreate_client(url, anon_key, options=...) Per-request client with JWT injection
channel.on_postgres_changes(event="UPDATE", ...) Postgres Changes subscription
await service_client.realtime.connect() Explicit WS connect (required before subscribe)
await channel.subscribe() Channel subscription lifecycle
await channel.send(type="broadcast", ...) Server-side broadcast from Python
await client.table(...).select("*").execute() PostgREST from background asyncio task

Friction log highlights

The friction_log.md documents 6 SDK issues found during implementation, including:

  • Per-request JWT injection requires mutating AsyncClientOptions().headers after construction (passing headers={} to the constructor drops DEFAULT_HEADERS)
  • realtime.connect() must be called manually before channel.subscribe() — no auto-connect
  • Realtime callbacks are sync-only with no documented async path (workaround: asyncio.get_running_loop().create_task())
  • channel.send() requires a prior channel.subscribe() on the broadcast channel

Tests

26 tests covering engine rule evaluation, JWT dependency injection, all router endpoints, SQL migration, and template rendering. No mocking of Supabase's network layer — mocks target only the SDK client objects.

26 passed in 0.50s

Test plan

  • Apply migration to a local Supabase project: supabase db push from examples/fastapi/
  • Configure .env with SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_ANON_KEY
  • Run: uv sync from repo root, then uv run uvicorn main:app --reload from examples/fastapi/
  • Sign in via /signin, create tasks and rules, verify live broadcast events appear in the feed
  • Confirm RLS: a user cannot update tasks not assigned to them

grdsdev and others added 21 commits June 23, 2026 09:18
…, and XSS in templates

- Fix hardcoded watch_column check in engine.py to evaluate any column
- Add WITH CHECK to tasks_update RLS policy to prevent reassignment
- Reject empty Bearer token in dependencies.py auth guard
- Replace innerHTML template literals with safe DOM construction in index.html and rules.html
- Call sign_out() in POST /auth/signout for programmatic clients
- Fix Python version in README (3.9+ -> 3.10+)
- Add tests for non-status column rule firing and empty Bearer token
…stAPI example

Adds a full E2E test suite that exercises the FastAPI Realtime Rule Engine
example against a real local Supabase instance (via the Supabase CLI).

What's new:
- examples/fastapi/supabase/config.toml — local Supabase config (port 54341)
- examples/fastapi/Makefile — start-infra, stop-infra, tests, e2e targets
- examples/fastapi/tests/e2e/ — 20 async E2E tests:
    - test_auth_e2e.py    — signup/signin/signout + 401 guard
    - test_tasks_e2e.py   — CRUD + RLS (user B blocked from updating user A's task)
    - test_rules_e2e.py   — CRUD + RLS (user B cannot see or delete user A's rules)
    - test_engine_e2e.py  — Postgres Changes → rule_events insert within 15s poll
- .github/workflows/e2e-fastapi.yml — CI workflow (py3.10 + py3.12 matrix);
  starts Supabase, applies migrations, runs the FastAPI app as a subprocess,
  executes E2E tests
- Root Makefile — adds fastapi.% delegation target

The engine E2E test is the key SDK stress test: it verifies the full path
AsyncRealtimeClient → Postgres Changes callback → PostgREST INSERT + broadcast
by polling rule_events after a task status update.
Three root causes fixed so all 20 E2E tests now pass:

1. Missing table-level GRANTs in migration
   Supabase's default privileges are set for supabase_admin; migrations
   run as postgres so authenticated and service_role had only REFERENCES/
   TRIGGER/TRUNCATE — no SELECT/INSERT/UPDATE/DELETE.  Added explicit
   GRANT statements at the end of 001_initial.sql.

2. Wrong Realtime payload key in engine.py
   supabase-py does NOT normalize Postgres Changes payloads to
   payload["new"] like the JS SDK.  The new record is at
   payload["data"]["record"].  engine.py was calling payload.get("new",{})
   which always returned {} and silently short-circuited the rule-match
   loop, producing zero rule_events.  Fixed to payload["data"]["record"].

3. E2E fixture teardown FK violations
   GoTrue admin.delete_user() deletes from auth.users but cannot cascade
   to application tables that REFERENCE auth.users without ON DELETE
   CASCADE.  Added _delete_user_and_data() helper that deletes rules and
   tasks for the user before calling delete_user.  Also fixed user_a/user_b
   to use a dedicated anon client for sign_up instead of the service client
   (sign_up was corrupting the service client's auth state).

Also added two new entries to friction_log.md for the payload key
mismatch and the service-role auth-state corruption.
- Drop `requires-python = ">=3.10"` → `">=3.9"` so the workspace syncs
  under all CI Python versions
- Replace `str | None` / `dict | None` union syntax with `Optional[...]`
  in test_dependencies.py and test_engine_e2e.py (PEP 604 is 3.10+)
- Fix test_engine.py unit tests to use the correct supabase-py Realtime
  payload structure — `{"data": {"record": {...}}}` — matching the engine
  fix in 0484de2 (was still using the old JS-style `{"new": {...}}` keys)
- Add `_TIMEOUT = 30` (up from 15) in test_engine_e2e.py for CI headroom
- Add `per-file-ignores = ["examples/fastapi/**" = ["B008"]]` in root
  pyproject.toml to allow FastAPI's Depends()-in-default-args pattern
@grdsdev
grdsdev marked this pull request as ready for review November 3, 2026 08:36
@grdsdev
grdsdev requested a review from o-santi as a code owner November 3, 2026 08:36
@grdsdev
grdsdev merged commit 6449e1e into main Nov 3, 2026
48 checks passed
@grdsdev
grdsdev deleted the claude/beautiful-bouman-d7a503 branch November 3, 2026 09:14
spydon pushed a commit that referenced this pull request Nov 3, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.34.0](v2.33.0...v2.34.0)
(2026-11-03)


### Features

* drop support for python 3.9 and 3.10
([#1915](#1915))
([37786ed](37786ed))
* **examples:** add FastAPI Realtime Rule Engine example
([#1519](#1519))
([6449e1e](6449e1e))
* **postgrest:** add client-level retry toggle
([#1900](#1900))
([e1916b5](e1916b5)),
closes [#1561](#1561)
* **postgrest:** add strip_nulls()
([#1760](#1760))
([c86ec9f](c86ec9f))
* **realtime:** add explicit REST API call for broadcast
([#1256](#1256))
([deec947](deec947))


### Bug Fixes

* **auth:** accept oauth clients without a name
([#1830](#1830))
([936458e](936458e))
* **auth:** accept pending secure email confirmations
([#1845](#1845))
([1e0c262](1e0c262))
* **auth:** avoid cancelling the running refresh callback
([#1716](#1716))
([606e0ac](606e0ac))
* **auth:** copy query_params in link_identity
([#1879](#1879))
([36e9952](36e9952))
* **auth:** emit PASSWORD_RECOVERY from verify_otp
([#1743](#1743))
([b571cc2](b571cc2))
* **auth:** emit SIGNED_IN when set_session keeps a valid token
([#1749](#1749))
([9c092db](9c092db))
* **auth:** handle empty OAuth client lists
([#1736](#1736))
([7d68e6b](7d68e6b))
* **auth:** keep existing session in sign-up and sign-in methods
([#1935](#1935))
([c3fa0f1](c3fa0f1))
* **auth:** keep existing session when verify_otp does not return one
([#1933](#1933))
([cae942d](cae942d))
* **auth:** keep expired session in initialize_from_storage when auto
refresh is off
([#1938](#1938))
([1436832](1436832))
* **auth:** keep or clear the session by scope when sign out fails
([#1824](#1824))
([de9932e](de9932e))
* **auth:** make code_verifier optional in CodeExchangeParams
([#1922](#1922))
([0b1d490](0b1d490))
* **auth:** map httpx transport errors to AuthRetryableError
([#1808](#1808))
([901f00d](901f00d))
* **auth:** normalize trailing slash in base URL
([#1766](#1766))
([de040ec](de040ec))
* **auth:** preserve phone numbers in MFA factor responses
([#1837](#1837))
([0e1cf4c](0e1cf4c))
* **auth:** preserve the PKCE recovery event
([#1906](#1906))
([cd0c4a3](cd0c4a3))
* **auth:** preserve the server error message on retryable 5xx responses
([#1833](#1833))
([c1727d5](c1727d5))
* **auth:** raise AuthInvalidJwtError for malformed JWTs in get_claims
([#1871](#1871))
([1aa8090](1aa8090))
* **auth:** raise AuthInvalidJwtError for malformed tokens in
set_session
([#1920](#1920))
([301ce7d](301ce7d))
* **auth:** read implicit grant params from the url fragment
([#1923](#1923))
([de2c6fc](de2c6fc))
* **auth:** require pyjwt>=2.15.1 for security fixes
([#1820](#1820))
([f41e80e](f41e80e))
* **auth:** send one refresh request per auto-refresh tick
([#1854](#1854))
([bc29bc6](bc29bc6))
* **auth:** send PKCE challenges for email and SSO flows
([#1682](#1682))
([66cd7ee](66cd7ee))
* **auth:** stop the admin API from sharing the client's header dict
([#1645](#1645))
([6b53131](6b53131))
* **auth:** use inspect.iscoroutinefunction in Timer
([#1875](#1875))
([4d5d430](4d5d430))
* **auth:** validate session and mfa enroll response after field
validation
([#1904](#1904))
([0310467](0310467))
* **functions:** drop the apiKey header on cross host redirects
([#1885](#1885))
([268557a](268557a))
* **functions:** keep the query string when invoking a function
([#1816](#1816))
([685469c](685469c))
* make the capability matrix verifiable
([#1867](#1867))
([804e7e2](804e7e2))
* **postgrest:** accept generated typeddict types in insert, upsert and
update ([#1939](#1939))
([1ae57b8](1ae57b8))
* **postgrest:** apply normalized timeout to internal httpx clients
([#1758](#1758))
([774e996](774e996))
* **postgrest:** clear basic auth when selecting bearer auth
([#1713](#1713))
([32e748b](32e748b))
* **postgrest:** describe non-JSON error responses with the decoded body
and a string code
([#1912](#1912))
([dd75df1](dd75df1))
* **postgrest:** drop pydantic v1 compat layer and align pydantic floor
([#1902](#1902))
([8ddd531](8ddd531))
* **postgrest:** escape quotes and backslashes in bulk column names
([#1948](#1948))
([7bebdeb](7bebdeb))
* **postgrest:** json-encode dictionary rpc arguments in get and head
requests ([#1899](#1899))
([86da454](86da454)),
closes [#1847](#1847)
* **postgrest:** keep the http client and basic auth in schema()
([#1672](#1672))
([c4e440c](c4e440c))
* **postgrest:** make APIError fields optional so partial error bodies
keep their message
([#1732](#1732))
([18bd628](18bd628))
* **postgrest:** omit columns for empty bulk inserts and upserts
([#1815](#1815))
([62a4e4d](62a4e4d))
* **postgrest:** preserve chaining after text search
([#1708](#1708))
([9588283](9588283))
* **postgrest:** preserve injected HTTPX client authentication
([#1838](#1838))
([79d14d9](79d14d9))
* **postgrest:** quote rpc array elements sent on GET and HEAD
([#1762](#1762))
([1bb870e](1bb870e))
* **postgrest:** raise on a non-http base_url
([#1881](#1881))
([610875d](610875d))
* **postgrest:** read the Proxy-Status error code when an error response
has no body
([#1621](#1621))
([34227d1](34227d1))
* **postgrest:** replace conflicting max-affected preferences
([#1827](#1827))
([5f989e1](5f989e1))
* **postgrest:** retry GET and HEAD requests on network errors
([#1777](#1777))
([1113f40](1113f40))
* **postgrest:** return no count when the total is unknown
([#1892](#1892))
([3c21359](3c21359))
* **postgrest:** send rpc list args as array literals on GET and HEAD
([#1752](#1752))
([50e7a4c](50e7a4c))
* **postgrest:** serialize null and booleans in in_() like other filters
([#1863](#1863))
([2cc0e03](2cc0e03))
* preserve functions timeout when replacing client options
([#1722](#1722))
([7fac036](7fac036))
* **realtime:** accept postgres change error arrays
([#1908](#1908))
([9c59f9a](9c59f9a))
* **realtime:** call late timeout handlers without a payload
([#1831](#1831))
([b6ddb74](b6ddb74))
* **realtime:** cap reconnect backoff at 60 seconds
([#1768](#1768))
([23ddbca](23ddbca))
* **realtime:** default postgres_changes schema to public
([#1884](#1884))
([a6e562d](a6e562d))
* **realtime:** deliver every broadcast to "*" listeners, ignoring case
([#1818](#1818))
([eae2838](eae2838))
* **realtime:** deprecate channel.join() and route it through subscribe
([#1928](#1928))
([8ac936c](8ac936c))
* **realtime:** honor the WebSocket opening timeout
([#1839](#1839))
([46efa11](46efa11))
* **realtime:** keep postgres bindings and report mismatches on join
replies ([#1929](#1929))
([cfb2159](cfb2159))
* **realtime:** keep references to background tasks
([#1888](#1888))
([f959593](f959593))
* **realtime:** keep the replacement channel when the old one closes
([#1877](#1877))
([af1d15d](af1d15d))
* **realtime:** notify subscribers of channel errors and closure
([#1846](#1846))
([bcecfc3](bcecfc3))
* **realtime:** reconnect when the server closes the socket normally
([#1675](#1675))
([4dafafd](4dafafd))
* **realtime:** redact api key and access tokens from debug logs
([#1750](#1750))
([17147f2](17147f2))
* **realtime:** rejoin channels on the stepped schedule
([#1916](#1916))
([db14ec5](db14ec5))
* **realtime:** rejoin the channel after a presence callback is added
while joined
([#1686](#1686))
([697170c](697170c))
* **realtime:** report CHANNEL_ERROR to subscribers when the connection
drops ([#1897](#1897))
([a00d1aa](a00d1aa))
* **realtime:** restart timeout after resending push
([#1654](#1654))
([da3a353](da3a353))
* **realtime:** serialize initial websocket handshakes
([#1853](#1853))
([c5150d6](c5150d6))
* **realtime:** use the client timeout for join and leave pushes
([#1685](#1685))
([198396d](198396d))
* **realtime:** wait for the join reply in subscribe
([#1940](#1940))
([c725015](c725015))
* **storage:** accept field names in MetadataConfiguration
([#1921](#1921))
([e0b5716](e0b5716))
* **storage:** allow null metadata in list_v2
([#1804](#1804))
([9d2f19c](9d2f19c))
* **storage:** close the upload handle we opened, and only that one
([#1607](#1607))
([2f3bf40](2f3bf40))
* **storage:** install the iceberg file backend
([#1873](#1873))
([b2cbf04](b2cbf04))
* **storage:** let upload headers override the client's headers
([#1942](#1942))
([09855c3](09855c3))
* **storage:** merge a partial sortBy with the default in list
([#1817](#1817))
([3fb5620](3fb5620))
* **storage:** parse analytics bucket error responses
([#1894](#1894))
([c5d0149](c5d0149))
* **storage:** read object info in exists() so auth and bucket errors
raise ([#1825](#1825))
([980ac9f](980ac9f))
* **storage:** require topK in vector index query
([#1946](#1946))
([a60e0d2](a60e0d2))
* **storage:** stop create_client from emitting the timeout deprecation
warning ([#1919](#1919))
([3349ad8](3349ad8))
* **supabase:** keep postgrest and storage timeouts in replace()
([#1809](#1809))
([795cad4](795cad4))
* **supabase:** keep the session token on every auth event that has one
([#1856](#1856))
([ca2fb99](ca2fb99))
* **supabase:** reject non-string url and key in create_client
([#1926](#1926))
([030cbc9](030cbc9))
* **supabase:** replace() preserves postgrest and storage timeouts
([#1840](#1840))
([772ee24](772ee24))


### Performance Improvements

* **postgrest:** speed up JSON parsing by 22x using json.loads
([#1571](#1571))
([c56c5bb](c56c5bb))


### Documentation

* add support policy
([#1917](#1917))
([f1392d4](f1392d4))
* Clarify when get_user() returns None
([#1569](#1569))
([3d8417f](3d8417f))
* document SDK compatibility with Supabase services
([#1870](#1870))
([1ff1e4a](1ff1e4a))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: supabase-py-releaser[bot] <254608689+supabase-py-releaser[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant